wt 0.2.0: worktree lifecycle — new, switch, list, rm, merge - #1
Conversation
need docker lived inside load_config_soft's config-present branch, so any repo with a worktree-kit.yml — even one that just omits runner:, which defaults to compose — made wt list die on a machine without the docker CLI. That broke the premise of the lifecycle layer: those commands need only git. Moved the check into require_config, after the HAS_CONFIG gate, so load_config_soft genuinely never exits and the ten pre-existing commands keep identical behavior (they all reach the check through require_config). Added a regression test that scrubs docker from PATH against a compose-defaulting config and asserts wt list still works. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
cmd_new handed the prepare hook to `wt run` as its payload, but `wt run` already runs hooks.prepare itself before execing its payload — running the hook twice on every `wt new`, on both runners. `wt new --server` made it three times, since `wt server` independently runs the hook as part of its own command string. Pass an inert `true` payload instead (wt run's own prepare step does the work), and skip the standalone prepare step entirely when --server is requested so `wt server`'s prepare run is the only one.
… removal Five review findings on the destructive path: the unmerged-commits guard failed open when a configured trunk did not resolve in the worktree; wt rm from inside the worktree being removed crashed after git worktree remove deleted the cwd, stranding the branch and skipping emit_cd; a failed server-stop was silently swallowed instead of reported; .dbready alone did not distinguish a kit-bootstrapped database from a merely-adopted one, so wt rm (and soon wt merge) could drop a hand-created database — added a narrower .dbowned marker and gated the drop on it; and the whole HAS_CONFIG=1 teardown branch (server-stop, the ownership gate, db_drop, redis_flush, the SLUG/N borrow) had zero test coverage. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
cmd_reset cleared .dbready but left a stale .dbowned behind. If the database is later replaced by hand and db_check adopts the replacement (touching only .dbready by design), the stale .dbowned would wrongly authorize wt rm/merge to drop a database this incarnation of the slug never created. Reset now clears both markers together; no heuristic added to recover ownership after a reset, per instruction — the accepted tradeoff is a "left in place" notice instead of a drop. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…d, untested guards covered - Rebase-conflict abort no longer trusts "rebase --abort" alone: falls back to checkout -f + an explicit update-ref of refs/heads/<branch> before the reset --hard, so a mid-rebase detached HEAD can never leave the branch ref silently rewritten while die claims it was restored. - Guarded the reset --hard fallbacks in the squash-commit-failure and fast-forward-failure paths so a failing reset can't have set -e kill the function before its recovery message (and the backup ref) reach the user. - Added cmd_rm's trunk-resolution guard (rev-parse --verify --quiet) so an unresolvable configured trunk dies with "cannot verify" instead of silently reading as "no commits ahead". - Test: feat/conflict now gets two commits so the squash always changes history shape, closing a same-second-timestamp coincidence that let the restore assertion pass even with the restore code deleted; added a rev-list --count shape assertion as the load-bearing check. - Test: backup ref assertion now checks value and timing (equals the pre-squash HEAD), not just that the ref name exists. - Test: added coverage for the trunk-not-at-primary and upstream-without---force guards, with master/branch "untouched" assertions rather than bare assert_fails — needed because both guards, when disabled, can still exit nonzero for unrelated incidental reasons (a fast-forward into the wrong branch, or git's own branch -d safety check) while real damage already happened. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…_drop
- test/lifecycle.sh: the "yields a space" doctor assertion matched the
fixture's own path text regardless of whether the WARN line fired
(cmd_doctor echoes the raw template unconditionally before the check
runs). Assert on "wt new will refuse it" instead, which only the WARN
line can produce. Verified load-bearing (RED/GREEN) along with the
branch-unique assertion, which was already sound.
- templates/compose/django.yml: comment out db_drop — its db_bootstrap and
own_db_env reveal no connection details at all, so the db/postgres host
guess risked DROP DATABASE against the wrong server. redis_flush stays
active.
- templates/{compose,host}/laravel.yml: note that the redis_flush host is
an inference (this template exports REDIS_DB, not a REDIS_URL).
- bin/wt: usage() said shell-init "makes switch cd"; the wrapper actually
intercepts switch/new/rm/merge.
…t aborts diagnosable Root cause of a flake (1-in-5 observed): remove_worktree_and_branch was called with force=0, so branch teardown used plain "git branch -d". After a squash+rebase, the branch's tip is a new commit object, and git's -d refuses to delete a branch that is merged into HEAD but not merged into its own configured upstream tracking ref — a real but redundant check here, since a successful --ff-only merge already proves every commit is reachable from trunk. Whether that check fired depended on a same-second commit-timestamp coincidence in the squash, and when it fired, "wt merge" died under set -e; since the test's "wt merge ... --force" call was a bare statement, that nonzero exit killed the whole suite silently — no FAIL line, no LIFECYCLE FAIL, just truncated output. - cmd_merge now passes force=1 to remove_worktree_and_branch (git branch -D), with a comment explaining why this is safe and warning against reverting it. Accepted consequence: worktree removal also becomes --force (discards untracked files) — not routed around, since refusing at this point would strand the user with trunk already advanced and the database already dropped. - Wrapped every bare "$WT" new/merge invocation in the Task 5 test block (not already inside assert_fails) with "|| fail ...", so a future regression produces a visible FAIL line and reaches LIFECYCLE FAIL instead of silently truncating the run. Verified: reverting force=1 alone reproduced the flake immediately (now failing loudly instead of silently); with both fixes applied, 30 consecutive full-suite runs plus 4 dash runs, all clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…emoval
Whole-branch review of the lifecycle layer found seven defects that the
per-task reviews could not see, because each only spanned one command.
bin/wt:
- remove_worktree_and_branch ran four bare git calls AFTER teardown had
already stopped the server, dropped the database and deleted every state
file. A failure there aborted under set -e with only git's stderr: no
"removed" line, no emit_cd, so a shell-wrapper caller was left in a
deleted directory. The worktree removal now dies with a message saying
the reclaim already happened; branch deletion only notes. The reachable
trigger was `git branch -d`, which asks "merged into HEAD?" — not the
"merged into trunk?" question cmd_rm already answered — so removing a
sibling worktree could strand a branch wt had verified.
- teardown_worktree borrowed SLUG and N for the target but not WT_PATH,
which is what runner_exec keys off. hooks.prepare therefore re-ran in the
CALLER's worktree on every rm/merge of another worktree, and db_drop /
redis_flush resolved relative paths against the wrong tree. The unused
_tp parameter was the tell.
- expand_path fed {parent}, {repo}, {branch_raw} and {home} to sed as
replacement text, where & means the whole match. The default template
uses two of them, so a repo directory named "R&D-app" silently expanded
{repo} to "R{repo}D-app" — and that path's last segment names the slug,
the port, the redis slot and the database.
- $HOME was dereferenced unguarded under set -u in the {home} expansion and
the rc-file scan; an unset HOME (env -i, some CI runners) died bare.
- The fish wrapper used a single-command variable override, a syntax error
on older fish, which would break wt entirely for those users.
- The no-.dbowned notice claimed the database was "adopted, not created by
wt". That is false for every worktree provisioned before this release —
.dbowned did not exist — which is exactly who sees it first on upgrade.
- A failed restore in cmd_merge printed "could not restore" and then died
saying "restored to <sha>", on the one path where the user is panicking.
One helper now prints a single, true outcome.
test/lifecycle.sh (84 -> 110 assertions):
- Both PATH=/usr/bin:/bin scrubs would have misbehaved in the container
suites: docker lives in /usr/bin on Linux, inverting the server-stop
assertion, and the truncation can remove the YAML backend. They now drop
only directories that hold docker, shimming git and the backend first.
- Guarded ~20 setup calls and captures that ran bare under set -eu, so a
regression FAILs with a diagnostic instead of truncating the run.
- First coverage of worktrees.path from the committed config (only the
.local path was exercised); stdout purity for rm and merge; the reason
behind the three refusals that matter; a real rebase-state check; and the
removal of an assertion that had no code path able to fail it.
Docs: README spells out that removal deletes the whole directory including
gitignored files, that merge tears down like `rm --force` rather than `rm`,
that hyphens fold in {branch}, and how pre-0.2.0 databases behave. The
design spec is amended in three places where the implementation knowingly
diverged, each marked as a resolved deviation.
Verified on /bin/sh and dash, six consecutive runs, 110 assertions, no
flake. test/linux.sh not run — docker daemon is down.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…anup The db_drop bullet told upgrading users that `wt reset <slug>` plus an --own-db cycle would re-bootstrap and re-mark a pre-0.2.0 database. It cannot: reset clears both markers, but ensure_own_db then runs db_check, which succeeds because the database still exists, and returns having touched only .dbready. .dbowned is written solely on the db_bootstrap path, which runs only when the database is missing. The bullet therefore contradicted its own case (b) two lines further down. It now says to drop the database by hand first, and explains why reset alone is not enough. Also amends spec §10.1 step 7, which still listed four state files while the implementation removes .dbowned as well — a fourth divergence, marked like the other three, and the status header updated to match. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
VSN2015
left a comment
There was a problem hiding this comment.
Summary & Overall Impression
This is a very well-engineered pull request that adds a complete worktree-lifecycle management layer (new, switch, list, rm, merge, shell-init) to wt.
Highlights
- Clean Separation of Concerns: Splitting config loading into
load_config_softandrequire_configallowswtlifecycle commands to work seamlessly in any Git repository without requiring Docker or aworktree-kit.yml. - Robust Safety Invariants in
wt rmandwt merge:- Pre-merge backup ref (
refs/wt/premerge/<slug>) created prior to any mutations. - Safe rebase conflict recovery (
bin/wtlines 1153–1166) that accounts for detachedHEADstate and explicitly restores the branch ref. - Ownership provenance gating via
.dbownedensuringwtnever drops databases it did not create. - CWD deletion protection (
bin/wtlines 1068, 1187) that steps the process out to$PRIMARYbeforegit worktree removedeletes the working directory.
- Pre-merge backup ref (
- Dedicated Stdout "CD Channel": Clean design separating machine-readable stdout path emission for shell integration from human stderr diagnostics.
- Comprehensive Test Suite:
test/lifecycle.shprovides 110 assertions verifying failure paths, rollback behavior, and edge cases.
Critical Findings & Bugs
1. Relative Invocation (./bin/wt) Fails in Subshells (cd Breaks "$0")
- Severity: High
- Location:
bin/wtlines 437, 739, 948, 955
Issue:
When running wt via a relative path (e.g. ./bin/wt new <branch> or bin/wt up), $0 is ./bin/wt. When cmd_new, cmd_up, or teardown_worktree executes a subshell that changes directory (cd "$_path" && "$0" ...), "$0" is evaluated relative to the new working directory, where ./bin/wt does not exist.
Reproducing failure:
$ ./bin/wt new feature-x
wt: prepare: true
./bin/wt: line 948: ./bin/wt: No such file or directory
wt: prepare hook failed — worktree left at ...Recommended Fix:
Resolve the absolute path to the wt executable once during initialization and use that resolved variable (WT_BIN or SELF) throughout:
# Early in bin/wt (or in ctx_init):
case "$0" in
/*) WT_BIN="$0" ;;
*/*) WT_BIN="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")" ;;
*) WT_BIN="$(command -v "$0" 2>/dev/null || echo "$0")" ;;
esacThen replace "$0" at lines 437, 739, 948, and 955 with "$WT_BIN".
2. wt doctor Crashes on runner: compose Repos If Docker CLI Is Not Installed
- Severity: Medium
- Location:
bin/wtlines 850–851
Issue:
cmd_doctor calls require_config, which calls need docker when runner: compose is set. If Docker is not installed on the host, wt doctor aborts immediately with wt: missing dependency: docker at line 850 before checking paths, YAML backends, fzf, shell integration, or overlays.
Furthermore, line 870 was specifically designed to inspect Docker status (docker: up / docker: DOWN), but it can never be reached if the Docker CLI is absent.
Recommended Fix:
Use load_config_soft instead of require_config inside cmd_doctor, and check Docker availability gracefully:
cmd_doctor() {
echo "wt $WT_VERSION"
echo "primary: $PRIMARY"
echo "worktree: $WT_PATH (slug: $SLUG, n: $N)"
load_config_soft
if [ "$HAS_CONFIG" = 1 ]; then
echo "config: $CONFIG (runner: $RUNNER, cache: $WT_CFG_SRC)"
else
echo "config: MISSING — run wt init"
fi
...
if [ "${RUNNER:-compose}" = compose ]; then
if ! command -v docker >/dev/null 2>&1; then
echo "docker: MISSING — install docker for runner: compose"
elif docker info >/dev/null 2>&1; then
echo "docker: up"
else
echo "docker: DOWN"
fi
fi3. Test Runner Ergonomics in test/lifecycle.sh
- Severity: Low / DX
- Location:
test/lifecycle.shline 7
Issue:
WT="${WT:-wt}" defaults to wt from PATH.
- If a developer runs
./test/lifecycle.shas instructed in the file header, it uses whateverwtbinary is installed in their global system path (or fails if not installed), rather than testingbin/wtfrom the repository. - Passing
WT=./bin/wt ./test/lifecycle.shfails because the test changes directory into temporary repos under$TMP, where relative./bin/wtdoes not exist.
Recommended Fix:
Default WT to an absolute path pointing to the local bin/wt:
if [ -z "${WT:-}" ]; then
_script_dir="$(cd "$(dirname "$0")/.." && pwd)"
if [ -f "$_script_dir/bin/wt" ]; then
WT="$_script_dir/bin/wt"
else
WT="wt"
fi
fiDetailed Review by Component
A. Lifecycle Commands & Execution
-
cmd_new:- Space checks on expanded paths (
bin/wtlines 921–926) prevent silent string splitting bugs in compose mounts. - Sed replacement character escaping via
_esc(bin/wtline 372) properly protects directory names with&and branch names with|. - Prepare hook execution logic cleanly avoids double-execution for both
--serverand standalone cases.
- Space checks on expanded paths (
-
cmd_merge:- All precondition guards fire before any history or refs are mutated.
- Fallback on conflict re-attaches
HEADand resets the branch ref to$_orig. --ff-onlymerge into trunk ensures no merge commits are created.
-
cmd_rm&teardown_worktree:- State files (
.pid,.port,.dbready,.dbowned,logs/*.log) are properly cleared. - Global variable borrowing (
SLUG,N,WT_PATH) correctly scopes teardown and prepare hooks to the target worktree.
- State files (
-
cmd_shell_init:- Using
env WT_SHELL_INTEGRATION=1 wt $argvfor Fish provides compatibility across older and newer Fish shell releases. - Shell function traps non-zero exits while allowing empty cd-channel paths.
- Using
B. Templates & Documentation
- All 10 stack templates in
templates/host/andtemplates/compose/are syntactically valid YAML and contain the newworktrees.path,worktrees.trunk,isolation.db_drop, andisolation.redis_flushdefinitions. README.mddocumentation is detailed, accurately explaining shell integration, the cd-channel mechanism, and warning about untracked/ignored file removal during teardown.
Adds a worktree-lifecycle layer to
wt, so a worktree can be created, found, merged, and fully torn down — reclaiming the server, database, Redis slot, and state files the kit provisioned for it.Inspired by worktrunk, which solves the complementary half: it creates and destroys worktrees but knows nothing about databases, servers, or isolation, so its
removeleaks every resourcewtprovisions.New commands
Plus
wt doctorchecks for the path template,fzf, and shell integration;worktrees.path/worktrees.trunkandisolation.db_drop/isolation.redis_flushacross all ten stack templates; README documentation; and a bump to 0.2.0.Design
git.require_configis split intoload_config_soft(never exits, setsHAS_CONFIG) and the existing strict wrapper. The ten pre-existing commands keep the strict wrapper and behave identically — verified by tracing every call site.cdto it.git worktree list --porcelain, never a recomputed template — so it finds worktrees created by hand or by other tooling.Safety
wt rmandwt mergestop servers, drop databases, flush Redis slots, and delete branches.wt mergealso rewrites history and does not prompt.wt mergewritesrefs/wt/premerge/<slug>before the first mutation; every failure path restores the branch and names that ref.rebase --abortalone is not enough.--ff-only, so a merge commit is structurally unreachable..dbownedprovenance marker proves the kit bootstrapped it. Databases merely adopted bydb_check— including every database created before 0.2.0 — are left in place with a notice. This errs toward keeping data.Testing
New
test/lifecycle.sh: 110 assertions, git-only (no docker, no YAML backend required). Runs on macOS and is wired into the Debian and Alpine container suites.Verified on macOS under both the system shell and
dash, includingdashexecutingbin/wtitself.Not yet verified: the container suites (
./test/linux.sh) could not run — no docker daemon on the development machine.CONTEXT.md§5 records that three of this codebase's eight historical bugs surfaced under BusyBoxashfirst. Please run./test/linux.shbefore merging. Two tests derive a docker-freePATHand are the most likely to need adjustment on a Linux layout.Also unexercised: both picker branches (
fzfis not installed; the sandbox has no controlling terminal) and the emittedfishwrapper.Note on
docs/Commits
18c11f5andf936d19adddocs/superpowers/{specs,plans}/— the design spec and implementation plan, ~1,900 lines. These were previously untracked. If you would rather keep them out of the repo, they can be dropped without touching any code.Review pointers
The changes most worth a careful look:
remove_worktree_and_branchandteardown_worktree(bin/wt) — shared by both destructive commands; teardown borrows and restores theSLUG/N/WT_PATHglobals deliberately.cmd_merge's failure paths — the restore ordering is load-bearing.expand_path's_eschelper — path values becomesedreplacement text, where&would otherwise mean "the whole match".ensure_own_db— the one change to pre-existing code, adding the provenance marker.🤖 Generated with Claude Code